#!/usr/bin/env python
# -*- coding: UTF-8 -*-

# Magpie - an open source discussion forum system.
#
# Copyright (C) 2009 The Magpie Team
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import datetime, exceptions, hashlib, logging

from ajiaojr.commons.appengine import counter
from ajiaojr.magpie import version as v

from google.appengine.api import memcache
from google.appengine.api import users
from google.appengine.ext import db

class UserGroup(db.Model):
  """Models a group of users.
  """
  name = db.StringProperty(required=True)
  description = db.TextProperty()
  
  def add_member(self, user):
    """Adds a new member to the user group.
    
    Args:
      user: The user instance.
    """
    user_in_group_key = 'g:%s-%d' % (user.user.email(), self.key().id())
    user_in_group = UserInGroup(key_name=user_in_group_key,
                                  club_user = user,
                                  user_group = self)
    user_in_group.put()
  
  def members(self):
    """Returns a list of all members of the user group.
    
    Returns:
      A list of all users in the user group.
    """
    query = db.Query(UserInGroup)
    query.filter('user_group = ', self)
    return [entry.club_user for entry in query]
  
  def __unicode__(self):
    return self.name

class ClubUser(db.Model):
  """Models a club user.
  """
  nickname = db.StringProperty()
  user = db.UserProperty(required=True)
  time_zone = db.StringProperty(required=True, default="UTC")
  signature = db.TextProperty()
  is_enabled = db.BooleanProperty(required = True, default = True)
  
  # Mining game related variables
  mine_dropped = db.BooleanProperty(required = True, default = False)
  
  def add_favourite(self, forum):
    """Adds a forum to the user's favourite forums.
    
    Args:
      forum: The forum to be added to the user's favourites.
    """
    fav_key_name = 'f:%s-%d' % (self.user.email(), forum.key().id())
    fav = UserFavourite(key_name = fav_key_name,
                             club_user = self, forum = forum)
    fav.put()
  
  @staticmethod
  def find_by_user(user):
    """Finds the club user account associated with the user.
    
    Args:
      user: the google user account.
      
    Returns:
      the developer account associated with the user account.
    """
    query = db.Query(ClubUser);
    query.filter('user = ', user)
    query.fetch(limit=1)
    
    if(query.count() == 0):
      def txn():
        logging.info('Creating club user account for user %s...' % user.email())
        m = hashlib.sha256()
        m.update(user.email())
        email_hash = m.hexdigest()
        existing_user = ClubUser.get_by_key_name('u%s' % email_hash)

        while existing_user is not None:
          email_hash.append('a')
          existing_user = ClubUser.get_by_key_name('u%s' % email_hash)
      
        club_user = ClubUser(
                             key_name = ('u%s' % email_hash),
                             user = user
                             )
        club_user.put()
        logging.info('Club user account for user %s created' %
                     user.email())
        return club_user
      
      club_user = db.run_in_transaction(txn)
      ClubConfig.get_instance().default_user_group.add_member(club_user)
    else:
      club_user = query[0]
      
    return club_user
  
  def get_bank_account(self):
    def txn():
      bank_account = BankAccount.get_by_key_name('bank:'+self.key().name())
      if bank_account is None:
        bank_account = BankAccount(key_name='bank:'+self.key().name(),
                                   holder = self)
        bank_account.put()
      
      return bank_account
    return db.run_in_transaction(txn)

  def get_favourites(self):
    """Finds the favourites of the current user.
    
    Returns:
      A list of Forums.
    """
    
    query = db.Query(UserFavourite)
    query.filter('club_user = ', self)
    
    return [entry.forum for entry in query]

  def get_received_messages(self, unread_only = False, include_deleted = False,
                            starting_index=None, count = 20):
    """Returns a list of messages this user received.
    
    Args:
      unread_only: if only return unread messages.
      include_deleted: if deleted messages should be included.
      starting_index: the starting index of the messages, used for pagination.
      count: the number of messages to return.
      
    Returns:
      A list of messages this user received.
    """
    query = db.Query(PrivateMessage)
    query.filter('recipient = ', self)
    
    if unread_only:
      query.filter('is_read = ', False)
    
    if not include_deleted:
      query.filter('is_deleted = ', False)
    
    if starting_index is not None:
      query.filter('message_index <= ', starting_index)
    query.order('-message_index')
    result = query.fetch(count+1)
    if len(result) == count+1:
      next = result[-1].message_index
      result = result[:count]
    else:
      next = None
    return result, next

  def get_sent_messages(self):
    """TODO: finish this"""
    pass
  
  def has_unread_message(self):
    """Returns if the user has any unread message(s).
    
    Return:
      True if the user has any unread message(s), False otherwise.
    """
    result, next = self.get_received_messages(unread_only = True,
                                              count = 0)
    return next is not None

  def is_in_group(self, group):
    """Checks if the user is in the given group.
    
    Args:
      group: the group to check whether the user is in.
      
    Returns:
      True if the user is in the group, False otherwise.
    """
    
    user_in_group = UserInGroup.get_by_key_name("g:%s-%d" % (self.user.email(),
                                                             group.key().id()))
    return user_in_group is not None
  
  def __cmp__(self, other):
    if not isinstance(other, ClubUser):
      return -1
    return cmp(self.user, other.user) 
    
class ForumCategory(db.Model):
  """Models forums categories.
  """
  
  name = db.StringProperty(required = True)
  description = db.TextProperty()
  sort_order = db.IntegerProperty(required = True, default = 0)
  is_deleted = db.BooleanProperty(required = True, default = False)
  
  # Access privilege settings
  user_group = db.ReferenceProperty(UserGroup, required = True)
  is_public = db.BooleanProperty(required=True, default=True)
  group_can_read = db.BooleanProperty(required=True, default=True)
  group_can_write = db.BooleanProperty(required=True, default=True)
  other_can_read = db.BooleanProperty(required=True, default=True)
  other_can_write = db.BooleanProperty(required=True, default=False)
  
  def user_can_read(self, user):
    """Checks if a user can read this forum category.
    
    The the following logic is applied:
    1. if the user is an admin, he can read the category regardless
    2. if the category is open for reads to everybody, the user can read
    3. if the user is in the owning group of the category and the group has
       read permission, the user can read.
    4. For all other conditions, the user can't read.
    
    Args:
      user: the user to check permission.
      
    Returns:
      True if the user can read the category, False otherwise.
    """
    
    if users.is_current_user_admin():
      return True
    
    if self.other_can_read:
      return True
    
    if user.is_in_group(self.user_group) and self.group_can_read:
      return True
    
    return False
  
  @staticmethod
  def get_forum_tree():
    """Returns the forum tree.
    
    Returns:
      The forum tree.
    """
    forum_tree = memcache.get('FORUM_TREE')
    
    if forum_tree is None:
      forum_tree = []
      query = db.Query(ForumCategory)
      query.filter('is_deleted = ', False)
      query.filter('is_public = ', True)
      query.order('-sort_order')
    
      for category in query:
        forums = Forum.get_by_category(category, True)
        category.forums = forums
        forum_tree.append(category)
        
      memcache.add('FORUM_TREE', forum_tree)

    return forum_tree
  
  @staticmethod
  def get_full_forum_tree():
    """Returns the full forum tree. Including deleted and private forums and 
    categories.
    
    Returns:
      The full forum tree. Including deleted and private forums and categories.
    """
    forum_tree = []
    query = db.Query(ForumCategory).order('-sort_order')
    
    for category in query:
      forums = Forum.get_by_category(category, False)
      category.forums = forums
      forum_tree.append(category)
      
    return forum_tree
  
  def __unicode__(self):
    return self.name

class Forum(db.Model):
  """Models a forum.
  """
  
  # Basic forum information
  name = db.StringProperty(required = True)
  description = db.TextProperty()
  is_deleted = db.BooleanProperty(required=True, default=False)
  sort_order = db.IntegerProperty(required = True, default = 0)
  
  # Access privilege information
  user_group = db.ReferenceProperty(UserGroup, required=True)
  is_public = db.BooleanProperty(required=True, default=True)
  group_can_read = db.BooleanProperty(required=True, default=True)
  group_can_write = db.BooleanProperty(required=True, default=True)
  other_can_read = db.BooleanProperty(required=True, default=True)
  other_can_write = db.BooleanProperty(required=True, default=False)
  
  # Category to which this forum belongs to
  forum_category = db.ReferenceProperty(ForumCategory, required = True)
  
  def add_post(self, post):
    """Adds a forum post to the forum
    
    Args:
      post: the post to be added to the forum.
    
    Returns:
      the added post.
    """
    post.post_index = str(post.post_time) +'|' + post.author.key().name()
    if post.root_post is None:
      counter.increment('THREAD_COUNT:'+str(self.key().id()))
      post.last_reply_index = post.post_index
      post.put()
    else:
      post.root_post.last_reply_index = post.post_index
      db.put([post.root_post, post])
      counter.increment('THREAD_REPLIES:'+str(post.root_post.key().id()))
        
    counter.increment('POST_COUNT:'+str(self.key().id()))
  
  def delete_post(self, post):
    """Deletes a post from a thread.
    
    Args:
      post: the post to be deleted.
    """
    if post.is_deleted:
      counter.increment('POST_COUNT:'+str(self.key().id()))
      if post.root_post is not None:
        counter.increment('THREAD_REPLIES:'+str(post.root_post.key().id()))
      else:
        counter.decrement('THREAD_COUNT:'+str(self.key().id()))
    else:
      counter.decrement('POST_COUNT:'+str(self.key().id()))
      if post.root_post is not None:
        counter.decrement('THREAD_REPLIES:'+str(post.root_post.key().id()))
      else:
        counter.decrement('THREAD_COUNT:'+str(self.key().id()))
    post.is_deleted = not post.is_deleted
    post.put()

  
  def get_moderators(self):
    """Returns a list of current moderators of the forum.
    
    Returns:
      A list of current moderators of the forum.
    """
    query = db.Query(ForumModerator)
    query.filter('forum = ', self)
    return [entry.club_user for entry in query]
  
  def get_non_pinned_threads(self,
                             include_deleted = False,
                             starting_index = None,
                             count = 20):
    """Gets all non-pinned threads of the forum.
    
    Args:
      starting_index: The starting post_index. This is used for pagnition.
      count: the number of threads to get. This option is used for pagnition.
      include_deleted: option to include deleted thread. This option is mostly
                       used by admins.
    
    Returns:
      A list of non-pinned forum threads.
    """
    query = db.Query(ForumPost)
    query.filter('forum = ', self)
    query.filter('root_post = ', None)
    query.filter('is_pinned = ', False)
    if not include_deleted:
      query.filter('is_deleted = ', False)
    if starting_index is not None:
      query.filter('last_reply_index <=', starting_index)
    query.order('-last_reply_index')
    result = query.fetch(count+1)
    if len(result) == count+1:
      next = result[-1].last_reply
      result = result[:count]
    else:
      next = None
    return result, next
    
  def get_pinned_threads(self, include_deleted = False):
    """Gets all pinned threads of the forum.
    
    Args:
      include_deleted: option to include deleted threads. This is mainly used
                       by admins.
    
    Returns:
      A list of all pinned threads of the forum.
    """
    query = db.Query(ForumPost)
    query.filter('forum = ', self)
    query.filter('root_post = ', None)
    query.filter('is_pinned = ', True)
    if not include_deleted:
      query.filter('is_deleted = ', False)
    query.order('-last_reply_index')
    return query

  def get_recommended_threads(self, count=20):
    """Returns a list of recommended threads of the forum.
    
    Args:
      count: The number of threads to be returned.
    
    Returns:
      A list of recommended threads of the forum.
    """
    query = db.Query(ForumPost)
    query.filter('forum = ', self)
    query.filter('root_post = ', None)
    query.filter('is_recommended = ', True)
    query.order('-last_reply_index')
    return query

  def is_banned(self, user):
    """Checks if the given user is banned
    
    Args:
      user: the user to check
      
    Returns:
      True if the user is banned, False otherwise.
    """
    banned = ForumBan.get_by_key_name('b:%s-%d' % (user.user.email(),
                                                   self.key().id()))
    return banned is not None

  def post_count(self):
    """Returns the number of posts of this forum.
    
    Returns:
      The number of posts of this forum.
    """
    return counter.get_count('POST_COUNT:'+self.key().id())

  def is_moderator(self, user):
    """Checks if the given user is the moderator of the forum.
    
    Args:
      user: the user to check.
      
    Returns:
      True if the user is an admin of the forum, False otherwise.
    """
    moderator = ForumModerator.get_by_key_name('m:%s-%d' % (user.user.email(),
                                                self.key().id()))
    return moderator is not None

  def user_can_read(self, user):
    """Checks if an user can read the forum
    
    The following logic is used:
    1. if the user is an admin, he can read the forum;
    2. if the user is an moderator of the forum, he can read the forum;
    3. if the user can read the category this forum belongs to:
      3.1 if the forum is open to all, then the user can read.
      3.2 if not, the user belongs to the forum owning group and the group has
          read permission, the user can read
    4. For all other cases, the user can't read the forum.
    
    Args:
      user: the user who is attempting to read the forum.
    
    Returns:
      True if the user has permission to read the forum, False otherwise.
    """
    if users.is_current_user_admin():
      return True
    
    if self.is_moderator(user):
      return True
    
    if self.forum_category.user_can_read(user):
      if self.other_can_read:
        return True
      
      if user.is_in_group(self.user_group) and self.group_can_read:
        return True
      
    return False
  
  def user_can_write(self, user):
    """Checks if the user can write to a forum.
    
    Args:
      user: the user to check.
    
    Returns:
      True if the user can write to the forum, False otherwise.
    """
    if users.is_current_user_admin():
      return True
    
    if self.is_moderator(user):
      return True
    
    if self.forum_category.user_can_read(user):
      if not self.is_banned(user):
        if self.other_can_write:
          return True
      
        if user.is_in_group(self.user_group) and self.group_can_write:
          return True
      
    return False

  def __unicode__(self):
    return self.description
  
  @staticmethod
  def get_by_category(category, public_only):
    query = db.Query(Forum)
    query.filter("forum_category = ", category)
    
    if public_only:
      query.filter("is_deleted = ", False)
      query.filter("is_public = ", True)
    
    query.order("-sort_order")
      
    return [forum for forum in query]
  
class ForumPost(db.Model):
  """
  Models a forum post.
  """
  
  # The parent and root thread.
  parent_post = db.SelfReferenceProperty(collection_name='parent_posts')
  root_post = db.SelfReferenceProperty(collection_name='root_posts')
  
  # Other attributes
  is_deleted = db.BooleanProperty(required=True, default=False)
  is_marked = db.BooleanProperty(required=True, default=False)
  is_pinned = db.BooleanProperty(required=True, default=False)
  is_locked = db.BooleanProperty(required=True, default=False)
  is_recommended = db.BooleanProperty(required=True, default=False)
  
  # Basic post information
  title = db.StringProperty(required = True)
  contents = db.TextProperty(required = True)
  post_time = db.DateTimeProperty(required = True, auto_now_add=True)
  author = db.ReferenceProperty(ClubUser, required = True)
  post_index = db.StringProperty(required = True, default = '0')
  forum = db.ReferenceProperty(Forum, required = True)
  
  # The amount of replies of this post.
  last_reply = db.DateTimeProperty(required = True, auto_now = True)
  last_reply_index = db.StringProperty(required = True, default = '0')
  
  author_ip = db.StringProperty(required=True, default='0.0.0.0')
  
  def get_all_replies(self, starting_index, count, include_deleted = False):
    """Gets all replies of the forum post.
    
    Args:
      starting_index: starting post index. This is used for pagnition.
      count: the number of replies to return. This is used for paginition.
      include_deleted: if deleted replies should be included. This is mainly
                       used for admin purposes.
    
    Returns:
      a list of all replies of the post.
    """
    query = db.Query(ForumPost)
    query.filter('root_post = ', self)
    if not include_deleted:
      query.filter('is_deleted = ', False)
    if starting_index is not None:
      query.filter('post_index >= ', starting_index)
    query.order('post_index')
    result = query.fetch(count+1)
    if len(result) == count+1:
      next = result[-1].post_index
      result = result[:count]
    else:
      next = None
    return result, next
  
  def read_count(self):
    """Returns the number times this thread has been read.
    
    Returns:
      The number of times this thread has been read.
    """
    return counter.get_count('THREAD_READS:'+str(self.key().id()))
  
  def reply_count(self):
    """Returns the number of replies this thread has.
    
    Return:
      The number of replies this thread has.
    """
    return counter.get_count('THREAD_REPLIES:'+str(self.key().id()))
  
class ForumBan(db.Model):
  """Relation entity to model a 'user is banned' relationship"""
  club_user = db.ReferenceProperty(ClubUser, required=True)
  forum = db.ReferenceProperty(Forum, required=True)

class ForumModerator(db.Model):
  """Relation entity to model a 'user moderates forum' relationship"""
  club_user = db.ReferenceProperty(ClubUser, required=True)
  forum = db.ReferenceProperty(Forum, required=True)
  
class UserFavourite(db.Model):
  """Relation entity to model a 'user has forum as favourite' relationship"""
  club_user = db.ReferenceProperty(ClubUser, required=True)
  forum = db.ReferenceProperty(Forum, required=True)
  
class UserInGroup(db.Model):
  """Relation entity to model a 'user is in group' relationship"""
  club_user = db.ReferenceProperty(ClubUser, required=True)
  user_group = db.ReferenceProperty(UserGroup, required=True)

class ClubConfig(db.Model):
  """Club configuration"""
  club_name = db.StringProperty(required = True, default = "Magpie")
  default_user_group = db.ReferenceProperty(UserGroup, required = True)
  information = db.TextProperty()
  is_enabled = db.BooleanProperty(required = True, default = True)
  is_read_only = db.BooleanProperty(required = True, default = False)
  
  # Bank settings
  is_bank_enabled = db.BooleanProperty(required = True, default = True)
  
  # PM settings
  is_pm_enabled = db.BooleanProperty(required = True, default = True)
  is_pm_read_only = db.BooleanProperty(required = True, default = False)
  
  @staticmethod
  def get_instance():
    """Returns an entity of the club config
    
    Returns:
      an entity of the club config.
    """
    instance = memcache.get('CLUB_CONFIG')
    if instance is None:
      instance = ClubConfig.get_by_key_name('CLUB_CONFIG')
      if instance is None:
        group = UserGroup(name = 'Default', description='Default Group')
        group.put()
        instance = ClubConfig(key_name='CLUB_CONFIG', default_user_group=group)
        instance.put()
      memcache.set('CLUB_CONFIG', instance)
    return instance

  def version(self):
    return v()

class OperationRecord(db.Model):
  """Record of an operation done"""
  operator = db.ReferenceProperty(ClubUser, required=True)
  target = db.ReferenceProperty(ForumPost, required=True)
  operation_time = db.DateTimeProperty(required=True, auto_now_add = True)
  description = db.StringProperty(required=True)

class PrivateMessage(db.Model):
  """Private message"""
  sender = db.ReferenceProperty(ClubUser,
                                required=True,
                                collection_name='senders')
  recipient = db.ReferenceProperty(ClubUser,
                                  required=True,
                                  collection_name='recipients')
  title = db.StringProperty(required=True)
  contents = db.TextProperty(required=True)
  message_time = db.DateTimeProperty(required=True, auto_now_add=True)
  
  is_deleted = db.BooleanProperty(required=True, default=False)
  is_read = db.BooleanProperty(required=True, default=False)
  message_index = db.StringProperty(required = True, default='0')
  
class InsufficientFundsError(exceptions.Exception):
  pass

class BankAccount(db.Model):
  holder = db.ReferenceProperty(ClubUser, required = True)
  balance = db.IntegerProperty(required = True, default=0)
  is_active = db.BooleanProperty(required = True, default = True)
  can_overdraft = db.BooleanProperty(required = True, default = False)
  
  def deposit(self, amount, message):
    def txn():
      if amount > 0:
        self.balance += amount
        self.put()
    db.run_in_transaction(txn)
    
    transaction = BankTransaction(bank_account = self,
                                  transaction_type = 'DEPOSIT',
                                  amount = amount,
                                  message = message,
                                  transaction_index=str(datetime.datetime.utcnow())+'|'+self.holder.key().name())
    transaction.put()
    
  def withdraw(self, amount, message):
    def txn():
      if amount > self.balance and not can_overdraft:
        raise InsufficientFundsError()
      else:
        self.balance -= amount
        self.put()
    db.run_in_transaction(txn)
    
    transaction = BankTransaction(bank_account = self,
                                  transaction_type = 'WITHDRAW',
                                  amount = amount,
                                  message = message,
                                  transaction_index=str(datetime.datetime.utcnow())+'|'+self.holder.key().name())
    transaction.put()

class BankTransaction(db.Model):
  bank_account = db.ReferenceProperty(BankAccount, required = True)
  transaction_time = db.DateTimeProperty(required = True, auto_now_add = True)
  transaction_type = db.StringProperty(required = True)
  amount = db.IntegerProperty(required = True, default = 0)
  message = db.TextProperty(required = False)
  transaction_index = db.StringProperty(required = True)
  
class MineGoldConfig(db.Model):
  is_enabled = db.BooleanProperty(required = True, default = True)
  drop_rate_numerator = db.IntegerProperty(required = True, default = 1)
  drop_rate_denominator = db.IntegerProperty(required = True, default = 100)
  
  @staticmethod
  def get_instance():
    """Returns an entity of the club config
    
    Returns:
      an entity of the club config.
    """
    instance = memcache.get('GAMES_MINE_GOLD_CONFIG')
    if instance is None:
      instance = MineGoldConfig.get_by_key_name('GAMES_MINE_GOLD_CONFIG')
      if instance is None:
        instance = MineGoldConfig(key_name='GAMES_MINE_GOLD_CONFIG')
        instance.put()
      memcache.set('GAMES_MINE_GOLD_CONFIG', instance)
    return instance
  
class GoldMiningStats(db.Model):
  club_user = db.ReferenceProperty(ClubUser, required = True)
  count = db.IntegerProperty(required = True, default = 0)
  amount = db.IntegerProperty(required = True, default = 0)
  gms_index = db.StringProperty(required = True)
